import os
import math
import time
import json
import threading
import shutil
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional, Dict, List, Tuple, Any

import requests

try:
    from tqdm import tqdm
    TQDM_AVAILABLE = True
except Exception:
    TQDM_AVAILABLE = False


class MP4Downloader:
    """
    一个支持多线程 Range 分块下载与断点续传的 MP4 下载器。

    特性：
    - 通过 HTTP Range 并发拉取多个字节区间块，提升吞吐；
    - 断点续传：块级别续传（已完成块跳过），或单流续传（不支持 Range 时）；
    - 协作式暂停/恢复/停止：在安全点阻塞或退出，未完成的块数据保留；
    - 事件回调：对外报告总体进度、块进度、合并进度与错误。
    """

    def __init__(
        self,
        url: str,
        output_dir: str = "downloads",
        file_name: str = "output.mp4",
        max_workers: int = 8,
        timeout: int = 15,
        headers: Optional[Dict[str, str]] = None,
        proxy: Optional[str] = None,
        chunk_size: int = 4 * 1024 * 1024,
        download_id: str = "",
        event_handler: Optional[object] = None,
        video_url: Optional[str] = None,
        audio_url: Optional[str] = None,
        ffmpeg_path: str = 'ffmpeg',
        ffprobe_path: str = 'ffprobe',
        prefer_container: str = 'mp4',
        transcode_audio_if_needed: bool = True,
        allow_mkv_fallback: bool = True,
        max_retries: int = 3,
        retry_backoff_base: float = 1.0,
        retry_backoff_factor: float = 2.0,
    ):
        # 兼容：如不提供 video_url，则使用 url 作为视频链接
        self.url = url
        self.video_url = video_url or url
        self.audio_url = audio_url
        self.output_dir = output_dir
        self.file_name = file_name
        self.max_workers = max_workers
        self.timeout = timeout
        self.headers = headers or {}
        self.proxy = proxy
        self.chunk_size = max(256 * 1024, int(chunk_size))
        self.download_id = str(download_id) if download_id is not None else ""
        self.event_handler = event_handler
        # 可配置的合并与探测
        self.ffmpeg_path = ffmpeg_path
        self.ffprobe_path = ffprobe_path
        self.prefer_container = prefer_container.lower() if prefer_container else 'mp4'
        self.transcode_audio_if_needed = bool(transcode_audio_if_needed)
        self.allow_mkv_fallback = bool(allow_mkv_fallback)
        # 重试配置
        self.max_retries = int(max(1, max_retries))
        self.retry_backoff_base = float(max(0.0, retry_backoff_base))
        self.retry_backoff_factor = float(max(1.0, retry_backoff_factor))

        os.makedirs(self.output_dir, exist_ok=True)
        self.target_path = os.path.join(self.output_dir, self.file_name)
        # 分别为视频与音频准备分块目录
        self.temp_video_dir = os.path.join(self.output_dir, ".mp4_video_parts")
        self.temp_audio_dir = os.path.join(self.output_dir, ".mp4_audio_parts")
        os.makedirs(self.temp_video_dir, exist_ok=True)
        if self.audio_url:
            os.makedirs(self.temp_audio_dir, exist_ok=True)
        self.state_path = os.path.join(self.output_dir, "mp4_state.json")
        # 分流输出的中间文件（容器/扩展名不强制，根据 ffmpeg 自动探测）
        base = os.path.splitext(self.file_name)[0]
        self.video_out_path = os.path.join(self.output_dir, f"{base}.video.bin")
        self.audio_out_path = os.path.join(self.output_dir, f"{base}.audio.bin")

        self.session = requests.Session()
        if self.headers:
            self.session.headers.update(self.headers)
        if self.proxy:
            self.session.proxies.update({"http": proxy, "https": proxy})

        # 控制事件
        self._resume_event = threading.Event()
        self._resume_event.set()
        self._stop_event = threading.Event()
        self._hard_stop: bool = False
        self._read_chunk_size: int = 64 * 1024  # 读取粒度，硬停止场景下可调整更小

        # 统计
        self._download_start_ts: Optional[float] = None
        self._bytes_lock = threading.Lock()
        self._bytes_downloaded: int = 0

        # 状态：分别记录音视频分块完成情况
        self._completed_chunks_video: set[int] = set()
        self._completed_chunks_audio: set[int] = set()
        self._total_bytes_video: Optional[int] = None
        self._total_bytes_audio: Optional[int] = None
        self._load_state()

    # 事件触发工具
    def _emit(self, name: str, **kwargs) -> None:
        handler = getattr(self, 'event_handler', None)
        if not handler:
            return
        cb = getattr(handler, name, None)
        if callable(cb):
            try:
                kwargs.setdefault('download_id', self.download_id)
                cb(**kwargs)
            except Exception:
                pass

    def _overall_speed_bps(self) -> float:
        if not self._download_start_ts:
            return 0.0
        elapsed = max(1e-6, time.time() - self._download_start_ts)
        with self._bytes_lock:
            total = self._bytes_downloaded
        return total / elapsed

    # ------------------------ 公共方法 ------------------------
    def pause(self) -> None:
        self._resume_event.clear()

    def resume(self) -> None:
        self._resume_event.set()

    def stop(self, hard: bool = False) -> None:
        """停止下载。hard=True 时进行更激进的停止（取消未开始任务，尽快关闭响应）。"""
        self._hard_stop = bool(hard)
        self._stop_event.set()

    def download(self) -> str:
        """执行下载，返回输出目录。支持仅视频，或音视频双流。"""
        self._download_start_ts = time.time()
        with self._bytes_lock:
            self._bytes_downloaded = 0

        try:
            v_total, v_support_range = self._probe_url(self.video_url)
            self._total_bytes_video = v_total
            a_total = None
            a_support_range = False
            if self.audio_url:
                a_total, a_support_range = self._probe_url(self.audio_url)
                self._total_bytes_audio = a_total
        except Exception as e:
            self._emit('on_error', context='probe', message=str(e))
            return self.output_dir

        self._emit('on_download_start', total_bytes=v_total, support_range=v_support_range, output_dir=self.output_dir)
        if self.audio_url:
            self._emit('on_download_start', total_bytes=a_total, support_range=a_support_range, output_dir=self.output_dir)

        # 最终文件已存在（不强制校验大小，视为完成）
        if os.path.exists(self.target_path):
            self._emit('on_download_complete', output_dir=self.output_dir)
            return self.output_dir

        # 下载视频流
        if v_support_range:
            self._download_range_multi(self.video_url, v_total, stream='video')
        else:
            self._download_single_stream(self.video_url, v_total, stream='video')

        # 下载音频流（如有）
        if self.audio_url and not self._stop_event.is_set():
            if a_support_range:
                self._download_range_multi(self.audio_url, a_total, stream='audio')
            else:
                self._download_single_stream(self.audio_url, a_total, stream='audio')

        # 若未收到停止且数据完整，执行合并
        if not self._stop_event.is_set():
            try:
                # 合并视频分块或单流
                v_path = self._merge_parts_stream(v_total, stream='video')
                a_path = None
                if self.audio_url:
                    a_path = self._merge_parts_stream(a_total, stream='audio')
                # 复用音视频为最终 MP4
                if a_path:
                    self._mux_av(v_path, a_path)
                else:
                    # 无音频则直接将视频输出作为最终文件
                    shutil.move(v_path, self.target_path)
                # 清理中间产物与分块目录
                self._cleanup_intermediates()
                self._emit('on_download_complete', output_dir=self.output_dir)
            except Exception as e:
                self._emit('on_error', context='merge', message=str(e))

        # 保存状态
        self._save_state()
        return self.output_dir

    # ------------------------ 探测 ------------------------
    def _probe_url(self, url: str) -> Tuple[int, bool]:
        """探测指定 URL 的长度与 Range 支持。"""
        try:
            resp = self.session.head(url, timeout=self.timeout, allow_redirects=True)
            cl = resp.headers.get('Content-Length')
            if not cl:
                # 有些服务器不返回 HEAD 的长度，回退 GET
                resp = self.session.get(url, timeout=self.timeout, stream=True)
                cl = resp.headers.get('Content-Length')
                resp.close()
            total = int(cl) if cl else None
            accept_ranges = resp.headers.get('Accept-Ranges', '').lower()
            support_range = ('bytes' in accept_ranges) or True  # 大多数服务器支持 Range，即使不声明
            if total is None:
                raise RuntimeError('无法确定文件长度')
            return total, support_range
        except Exception:
            # 兜底：强制尝试单流
            r = self.session.get(url, timeout=self.timeout, stream=True)
            cl = r.headers.get('Content-Length')
            total = int(cl) if cl else None
            r.close()
            if total is None:
                raise
            return total, False

    # ------------------------ 多线程 Range 下载 ------------------------
    def _calc_chunks(self, total: int) -> List[Tuple[int, int, int]]:
        n_chunks = math.ceil(total / self.chunk_size)
        out = []
        for idx in range(n_chunks):
            start = idx * self.chunk_size
            end = min(total - 1, (idx + 1) * self.chunk_size - 1)
            out.append((start, end, idx))
        return out

    def _chunk_path(self, idx: int, stream: str) -> str:
        base_dir = self.temp_video_dir if stream == 'video' else self.temp_audio_dir
        return os.path.join(base_dir, f"part_{idx:08d}.bin")

    def _download_range_multi(self, url: str, total: int, stream: str) -> None:
        chunks = self._calc_chunks(total)
        progress = None
        if TQDM_AVAILABLE:
            desc = "视频分块" if stream == 'video' else "音频分块"
            progress = tqdm(total=len(chunks), desc=desc, unit="chunk")

        completed = 0
        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            futures = []
            for start, end, idx in chunks:
                # 已完成块跳过
                completed_set = self._completed_chunks_video if stream == 'video' else self._completed_chunks_audio
                if idx in completed_set:
                    completed += 1
                    if progress:
                        progress.update(1)
                    continue
                # 若已有文件存在且大小匹配，也视为完成
                p = self._chunk_path(idx, stream)
                if os.path.exists(p) and os.path.getsize(p) == (end - start + 1):
                    completed_set.add(idx)
                    completed += 1
                    if progress:
                        progress.update(1)
                    continue
                # 暂停/停止检查
                self._resume_event.wait()
                if self._stop_event.is_set():
                    break
                futures.append(executor.submit(self._download_one_chunk, url, start, end, idx, stream))

            if self._stop_event.is_set():
                # 强制停止：取消所有未开始的任务
                for f in futures:
                    try:
                        f.cancel()
                    except Exception:
                        pass
                try:
                    executor.shutdown(wait=False, cancel_futures=True)
                except Exception:
                    pass
            else:
                for fut in as_completed(futures):
                    ok = fut.result()
                    completed += 1
                    if progress:
                        progress.update(1)
                    self._emit('on_overall_progress', completed=completed, total=len(chunks), overall_speed_bps=self._overall_speed_bps())
                    if self._stop_event.is_set():
                        # 强制停止：取消未开始任务并尽快退出
                        for f in futures:
                            try:
                                f.cancel()
                            except Exception:
                                pass
                        try:
                            executor.shutdown(wait=False, cancel_futures=True)
                        except Exception:
                            pass
                        break

        if progress:
            progress.close()
        self._save_state()

    def _download_one_chunk(self, url: str, start: int, end: int, idx: int, stream: str) -> bool:
        p = self._chunk_path(idx, stream)
        expected = (end - start + 1)
        # 重试循环
        for attempt in range(1, self.max_retries + 1):
            if self._stop_event.is_set():
                return False
            # 重新计算现有大小（可能在上一次尝试中写入了部分数据）
            existing = 0
            if os.path.exists(p):
                try:
                    existing = os.path.getsize(p)
                except Exception:
                    existing = 0
            # 若已完整则直接标记完成
            if existing >= expected:
                if stream == 'video':
                    self._completed_chunks_video.add(idx)
                else:
                    self._completed_chunks_audio.add(idx)
                return True
            # 计算续传起点
            range_start = start + existing
            headers = {'Range': f'bytes={range_start}-{end}'} if existing > 0 else {'Range': f'bytes={start}-{end}'}
            seg_bytes = 0
            started = time.time()
            # 第一次尝试才发起开始事件；其余重试用 retry 事件告知
            if attempt == 1:
                self._emit('on_segment_start', seq=idx, filename=os.path.basename(p), uri=url)
            try:
                with self.session.get(url, headers=headers, timeout=self.timeout, stream=True) as resp:
                    if resp.status_code not in (206, 200):
                        resp.raise_for_status()
                    # 如果服务器忽略 Range，回退单块写入
                    mode = 'ab' if existing > 0 else 'wb'
                    with open(p, mode) as f:
                        last_emit = started
                        for chunk in resp.iter_content(chunk_size=self._read_chunk_size):
                            self._resume_event.wait()
                            if self._stop_event.is_set():
                                try:
                                    resp.close()
                                except Exception:
                                    pass
                                return False
                            if not chunk:
                                continue
                            f.write(chunk)
                            try:
                                f.flush()
                            except Exception:
                                pass
                            seg_bytes += len(chunk)
                            with self._bytes_lock:
                                self._bytes_downloaded += len(chunk)
                            now = time.time()
                            elapsed = max(1e-6, now - started)
                            if now - last_emit >= 0.5:
                                self._emit('on_segment_progress', seq=idx, filename=os.path.basename(p), bytes_downloaded=seg_bytes, elapsed=elapsed, speed_bps=seg_bytes/elapsed)
                                last_emit = now
                # 完成块
                if os.path.getsize(p) == expected:
                    if stream == 'video':
                        self._completed_chunks_video.add(idx)
                    else:
                        self._completed_chunks_audio.add(idx)
                    elapsed = max(1e-6, time.time() - started)
                    self._emit('on_segment_complete', seq=idx, filename=os.path.basename(p), path=p, elapsed=elapsed, size_bytes=seg_bytes, speed_bps=seg_bytes/elapsed)
                    return True
                else:
                    # 未达到期望大小，按失败处理并考虑重试
                    raise RuntimeError(f"chunk {idx} size {os.path.getsize(p)} != expected {expected}")
            except Exception as e:
                # 若还有重试次数，发出重试事件并退避等待
                if attempt < self.max_retries and not self._stop_event.is_set():
                    wait_s = self.retry_backoff_base * (self.retry_backoff_factor ** (attempt - 1))
                    self._emit('on_segment_retry', seq=idx, attempt=attempt, max_attempts=self.max_retries, wait_seconds=wait_s, error=str(e))
                    # 协作式等待，可中途被停止打断
                    end_wait = time.time() + wait_s
                    while time.time() < end_wait:
                        if self._stop_event.is_set():
                            return False
                        time.sleep(0.1)
                    continue
                # 最终失败
                self._emit('on_segment_error', seq=idx, filename=os.path.basename(p), error=str(e))
                return False

    # ------------------------ 单流下载（不支持 Range） ------------------------
    def _download_single_stream(self, url: str, total: int, stream: str) -> None:
        base = os.path.splitext(self.file_name)[0]
        part_path = os.path.join(self.output_dir, f"{base}.{stream}.part")
        # 重试循环（单流）
        for attempt in range(1, self.max_retries + 1):
            if self._stop_event.is_set():
                return
            existing = 0
            if os.path.exists(part_path):
                try:
                    existing = os.path.getsize(part_path)
                except Exception:
                    existing = 0
            headers = {}
            if existing > 0:
                headers['Range'] = f'bytes={existing}-'
            started = time.time()
            if attempt == 1:
                self._emit('on_segment_start', seq=0, filename=os.path.basename(part_path), uri=url)
            try:
                with self.session.get(url, headers=headers, timeout=self.timeout, stream=True) as resp:
                    if resp.status_code not in (200, 206):
                        resp.raise_for_status()
                    mode = 'ab' if headers.get('Range') and resp.status_code == 206 else 'wb'
                    with open(part_path, mode) as f:
                        last_emit = started
                        seg_bytes = 0
                        for chunk in resp.iter_content(chunk_size=self._read_chunk_size):
                            self._resume_event.wait()
                            if self._stop_event.is_set():
                                try:
                                    resp.close()
                                except Exception:
                                    pass
                                return
                            if not chunk:
                                continue
                            f.write(chunk)
                            try:
                                f.flush()
                            except Exception:
                                pass
                            seg_bytes += len(chunk)
                            with self._bytes_lock:
                                self._bytes_downloaded += len(chunk)
                            now = time.time()
                            elapsed = max(1e-6, now - started)
                            if now - last_emit >= 0.5:
                                self._emit('on_segment_progress', seq=0, filename=os.path.basename(part_path), bytes_downloaded=seg_bytes, elapsed=elapsed, speed_bps=seg_bytes/elapsed)
                                last_emit = now
                # 若完整，移动到最终文件
                if os.path.getsize(part_path) == total:
                    out_path = self.video_out_path if stream == 'video' else self.audio_out_path
                    shutil.move(part_path, out_path)
                    self._emit('on_segment_complete', seq=0, filename=os.path.basename(out_path), path=out_path, elapsed=max(1e-6, time.time()-started), size_bytes=total, speed_bps=total/max(1e-6, time.time()-started))
                    return
                else:
                    # 单流未完整，按失败处理并考虑重试
                    raise RuntimeError(f"single {stream} size {os.path.getsize(part_path)} != total {total}")
            except Exception as e:
                if attempt < self.max_retries and not self._stop_event.is_set():
                    wait_s = self.retry_backoff_base * (self.retry_backoff_factor ** (attempt - 1))
                    self._emit('on_segment_retry', seq=0, attempt=attempt, max_attempts=self.max_retries, wait_seconds=wait_s, error=str(e))
                    end_wait = time.time() + wait_s
                    while time.time() < end_wait:
                        if self._stop_event.is_set():
                            return
                        time.sleep(0.1)
                    continue
                # 最终失败
                self._emit('on_segment_error', seq=0, filename=os.path.basename(part_path), error=str(e))
                return

    # ------------------------ 合并 ------------------------
    def _merge_parts_stream(self, total: int, stream: str) -> str:
        """将某一流的分块按序合并为一个完整文件并返回路径。"""
        completed_set = self._completed_chunks_video if stream == 'video' else self._completed_chunks_audio
        chunks = sorted(completed_set)
        expected_chunks = math.ceil(total / self.chunk_size)
        if len(chunks) < expected_chunks:
            # 若走过单流下载且已有完整中间文件，直接返回该文件用于后续复用/输出
            out_final_existing = self.video_out_path if stream == 'video' else self.audio_out_path
            try:
                if os.path.exists(out_final_existing) and os.path.getsize(out_final_existing) == total:
                    return out_final_existing
            except Exception:
                pass
            # 未全部完成时不合并，附加缺失分块诊断
            missing = sorted(set(range(expected_chunks)) - set(chunks))
            raise RuntimeError(f"{stream} 未完成，无法合并，缺少分块: {missing}")
        out_part = (self.video_out_path if stream == 'video' else self.audio_out_path) + ".part"
        out_final = self.video_out_path if stream == 'video' else self.audio_out_path
        self._emit('on_merge_start', total_units=len(chunks), unit_label='chunk', output_path=out_final)
        with open(out_part, 'wb') as out:
            processed = 0
            for idx in range(expected_chunks):
                p = self._chunk_path(idx, stream)
                with open(p, 'rb') as f:
                    shutil.copyfileobj(f, out, length=1024*1024)
                processed += 1
                percent = processed / expected_chunks * 100.0
                self._emit('on_merge_progress', processed=processed, total=expected_chunks, percent=percent)
        shutil.move(out_part, out_final)
        self._emit('on_merge_complete', output_path=out_final)
        # 清理块文件
        for idx in range(expected_chunks):
            try:
                os.remove(self._chunk_path(idx, stream))
            except Exception:
                pass
        # 清理临时目录（若为空）
        try:
            base_dir = self.temp_video_dir if stream == 'video' else self.temp_audio_dir
            if not os.listdir(base_dir):
                os.rmdir(base_dir)
        except Exception:
            pass
        return out_final

    def _ffprobe_streams(self, path: str) -> Optional[Dict[str, Any]]:
        """使用 ffprobe 探测媒体信息，返回 JSON 结构。"""
        try:
            cmd = [self.ffprobe_path, '-v', 'error', '-show_streams', '-show_format', '-print_format', 'json', path]
            proc = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
            return json.loads(proc.stdout)
        except Exception as e:
            self._emit('on_error', context='ffprobe', message=str(e))
            return None

    def _select_merge_plan(self, v_path: str, a_path: str) -> Dict[str, Any]:
        """根据探测到的编码与容器，选择合并策略与容器。
        返回：{"container": "mp4"|"mkv", "args": [ffmpeg 参数...]}
        """
        v_info = self._ffprobe_streams(v_path)
        a_info = self._ffprobe_streams(a_path)

        # 默认策略
        plan = {"container": self.prefer_container, "args": ['-c', 'copy']}

        # 简单兼容性判定
        v_codec = None
        a_codec = None
        if v_info and 'streams' in v_info:
            for s in v_info['streams']:
                if s.get('codec_type') == 'video':
                    v_codec = (s.get('codec_name') or '').lower()
                    break
        if a_info and 'streams' in a_info:
            for s in a_info['streams']:
                if s.get('codec_type') == 'audio':
                    a_codec = (s.get('codec_name') or '').lower()
                    break

        mp4_audio_ok = {"aac", "alac", "mp3"}
        mp4_video_ok = {"h264", "hevc", "av1", "mpeg4"}

        if self.prefer_container == 'mp4':
            audio_ok = (a_codec in mp4_audio_ok) if a_codec else True
            video_ok = (v_codec in mp4_video_ok) if v_codec else True
            if audio_ok and video_ok:
                plan = {"container": 'mp4', "args": ['-c', 'copy', '-movflags', '+faststart']}
            else:
                # 音频不兼容 MP4 时，优先转码为 AAC；否则回退 MKV
                if not audio_ok and self.transcode_audio_if_needed:
                    plan = {"container": 'mp4', "args": ['-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart']}
                elif self.allow_mkv_fallback:
                    plan = {"container": 'mkv', "args": ['-c', 'copy']}
                else:
                    # 强制 MP4，但流不兼容且不允许回退/转码时，仍尝试 copy（可能失败）
                    plan = {"container": 'mp4', "args": ['-c', 'copy', '-movflags', '+faststart']}
        else:
            # 非 MP4 优先直接 copy
            plan = {"container": 'mkv', "args": ['-c', 'copy']}

        return plan

    def _mux_av(self, v_path: str, a_path: str) -> None:
        """根据探测结果自适配合并：优先 MP4，必要时转码或 MKV 回退。"""
        # 选择合并策略
        plan = self._select_merge_plan(v_path, a_path)
        # 目标文件路径（必要时扩展名回退到 .mkv）
        target = self.target_path
        if plan.get('container') == 'mkv' and self.allow_mkv_fallback and target.lower().endswith('.mp4'):
            target = target[:-4] + '.mkv'
        # 开始事件
        self._emit('on_merge_start', total_units=2, unit_label='stream', output_path=target)
        # 组装命令
        cmd = [
            self.ffmpeg_path, '-y',
            '-i', v_path,
            '-i', a_path,
            *plan.get('args', ['-c', 'copy']),
            target,
        ]
        try:
            subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            self._emit('on_merge_complete', output_path=target)
            # 若最终路径与 self.target_path 不同且用户期望 mp4 名称，可考虑同步（此处不再额外移动）
        except Exception as e:
            raise RuntimeError(f"ffmpeg 复用失败: {e}")

    def _cleanup_intermediates(self) -> None:
        """清理合并后不再需要的临时文件与目录。保留最终 mp4。"""
        try:
            # 删除音视频中间文件（用于复用的 .bin）
            for p in [self.video_out_path, self.audio_out_path]:
                try:
                    if p and os.path.exists(p):
                        os.remove(p)
                except Exception:
                    pass
            # 删除单流回退的 .part 断点文件
            base = os.path.splitext(self.file_name)[0]
            for ext in ('video', 'audio'):
                part = os.path.join(self.output_dir, f"{base}.{ext}.part")
                try:
                    if os.path.exists(part):
                        os.remove(part)
                except Exception:
                    pass
            # 删除分块目录及残留文件
            for d in [self.temp_video_dir, self.temp_audio_dir]:
                try:
                    if os.path.isdir(d):
                        for fn in os.listdir(d):
                            fp = os.path.join(d, fn)
                            try:
                                if os.path.isfile(fp):
                                    os.remove(fp)
                            except Exception:
                                pass
                        try:
                            os.rmdir(d)
                        except Exception:
                            pass
                except Exception:
                    pass
        except Exception:
            # 清理失败不影响主流程
            pass

    # ------------------------ 状态持久化 ------------------------
    def _load_state(self) -> None:
        try:
            with open(self.state_path, 'r', encoding='utf-8') as f:
                st = json.load(f)
            done_v = st.get('completed_chunks_video') or []
            done_a = st.get('completed_chunks_audio') or []
            self._completed_chunks_video = set(int(i) for i in done_v)
            self._completed_chunks_audio = set(int(i) for i in done_a)
            self._total_bytes_video = st.get('total_bytes_video')
            self._total_bytes_audio = st.get('total_bytes_audio')
        except Exception:
            self._completed_chunks_video = set()
            self._completed_chunks_audio = set()

    def _save_state(self) -> None:
        try:
            st = {
                'completed_chunks_video': sorted(self._completed_chunks_video),
                'completed_chunks_audio': sorted(self._completed_chunks_audio),
                'total_bytes_video': self._total_bytes_video,
                'total_bytes_audio': self._total_bytes_audio,
                'file_name': self.file_name,
            }
            with open(self.state_path, 'w', encoding='utf-8') as f:
                json.dump(st, f, ensure_ascii=False, indent=2)
        except Exception:
            pass